이벤트 버블링과 캡처링을 위임 패턴으로 이해하기

이벤트 버블링과 캡처링을 위임 패턴으로 이해하기

한눈에 보기

이벤트 위임은 공통 조상에 리스너를 하나 두고 event.target에서 실제 대상을 찾는 패턴이다. 동적으로 추가되는 자식도 처리할 수 있다.

목차

왜 이 문제가 생기는가

목록의 삭제 버튼마다 이벤트 리스너를 붙이는 코드는 처음에는 자연스럽다. 하지만 항목이 동적으로 추가되면 새 버튼에도 리스너를 붙여야 하고, 렌더링을 반복하는 코드와 이벤트 등록 코드가 쉽게 얽힌다. 반대로 상위 요소에 리스너 하나만 붙이면 코드가 단순해지지만, 클릭된 실제 요소를 잘못 판단하거나 다른 기능의 이벤트 전파를 막는 버그가 생길 수 있다.

이벤트 위임은 단지 리스너 수를 줄이는 요령이 아니다. 브라우저 이벤트가 DOM 경로를 이동하는 방식을 이용해 이벤트를 처리할 책임의 경계를 정하는 패턴이다.

이 글의 예제에 관하여

상품 목록과 메뉴 코드는 이벤트 흐름을 설명하기 위해 만든 예시이며 실제 프로젝트 코드를 사용하지 않았다.

이벤트가 이동하는 세 단계

사용자가 중첩된 버튼을 클릭하면 이벤트는 바로 버튼에서만 실행되지 않는다. 대략 다음 경로를 지난다.

flowchart LR
    W1[window] -->|capture| D1[document]
    D1 -->|capture| L1[ul.product-list]
    L1 -->|capture| B[button target]
    B -->|bubble| L2[ul.product-list]
    L2 -->|bubble| D2[document]
    D2 -->|bubble| W2[window]
  1. 캡처링 단계: 바깥쪽 조상에서 이벤트 대상 방향으로 내려간다.
  2. 타깃 단계: 실제 이벤트 대상에 도달한다.
  3. 버블링 단계: 대상에서 다시 조상 방향으로 올라간다.

addEventListener의 기본 리스너는 버블링 단계에서 호출된다. 캡처링 단계에서 처리하려면 세 번째 인자에 { capture: true }를 사용한다.

const panel = document.querySelector(".panel");

panel.addEventListener(
  "click",
  () => console.log("panel capture"),
  { capture: true },
);

panel.addEventListener("click", () => {
  console.log("panel bubble");
});

target과 currentTarget의 차이

위임 코드를 이해할 때 가장 중요한 두 값은 event.targetevent.currentTarget이다.

속성 의미 위임 예제에서의 값
target 이벤트가 시작된 실제 요소 버튼 안의 svg 또는 span일 수 있음
currentTarget 현재 리스너가 실행 중인 요소 리스너를 등록한 ul
<ul class="product-list">
  <li data-product-id="p-101">
    <button type="button" data-action="remove">
      <svg aria-hidden="true"><!-- 아이콘 --></svg>
      <span>삭제</span>
    </button>
  </li>
</ul>

사용자가 글자를 클릭하면 targetspan이고 아이콘을 클릭하면 svg일 수 있다. 따라서 다음 코드는 버튼 자체를 정확히 클릭했을 때만 동작한다.

// 좋지 않은 예: 자식 아이콘을 클릭하면 조건을 통과하지 못한다.
list.addEventListener("click", (event) => {
  if (!event.target.matches("button[data-action='remove']")) return;
  removeProduct(event.target.dataset.productId);
});

이벤트 위임을 안전하게 구현하기

실제 대상에서 원하는 조상까지 올라가며 검색하는 closest()를 사용하면 버튼 내부 요소를 클릭한 경우도 처리할 수 있다.

const list = document.querySelector(".product-list");

list.addEventListener("click", (event) => {
  const removeButton = event.target.closest(
    "button[data-action='remove']",
  );

  if (!removeButton) return;
  if (!list.contains(removeButton)) return;

  const item = removeButton.closest("li[data-product-id]");
  if (!item) return;

  removeProduct(item.dataset.productId);
});

contains() 검사는 리스너 경계 밖에서 찾은 요소를 실수로 처리하지 않도록 의도를 분명히 한다. 특히 위임 컨테이너 안에 다른 트리와 연결되는 복잡한 UI가 있거나, 선택자 검색 범위가 바뀔 수 있을 때 방어 코드로 유용하다.

여러 동작을 처리한다면 액션 이름을 기준으로 분기할 수 있다.

const handlers = {
  remove: (item) => removeProduct(item.dataset.productId),
  favorite: (item) => toggleFavorite(item.dataset.productId),
};

list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;

  const item = button.closest("li[data-product-id]");
  const handler = handlers[button.dataset.action];

  if (item && handler) handler(item);
});

동적으로 추가되는 요소 처리하기

개별 등록 방식은 새 항목을 추가할 때 리스너 등록도 반복해야 한다.

function appendProduct(product) {
  const item = renderProductItem(product);
  list.append(item);

  item.querySelector("[data-action='remove']")
    .addEventListener("click", () => removeProduct(product.id));
}

위임 방식에서는 이벤트가 상위 list까지 버블링하므로 새로 추가된 자식도 기존 리스너가 처리한다. 렌더링 함수가 이벤트 생명주기를 따로 관리하지 않아도 된다는 점이 핵심 장점이다.

다만 위임 범위를 무조건 document까지 올리면 관련 없는 모든 클릭을 한 곳에서 분기하게 된다. 기능을 소유한 가장 가까운 안정적인 조상을 위임 경계로 잡는 편이 유지보수하기 쉽다.

전파 중단과 기본 동작 취소는 다르다

비슷하게 보이는 API가 서로 다른 문제를 해결한다.

API 막는 것 막지 않는 것
preventDefault() 링크 이동, 폼 제출 같은 기본 동작 이벤트 전파
stopPropagation() 다음 조상 또는 자손으로의 전파 같은 요소의 다른 리스너, 기본 동작
stopImmediatePropagation() 전파와 같은 요소의 뒤이은 리스너 기본 동작

모달 내부 클릭이 바깥 닫기 리스너까지 도달하지 않게 하려고 모든 곳에서 stopPropagation()을 호출하면, 상위에서 동작하는 분석 이벤트나 키보드 보조 기능도 함께 끊길 수 있다. 전파를 막기 전에 상위 핸들러가 실제 대상이나 경로를 검사하도록 설계할 수 없는지 먼저 확인한다.

overlay.addEventListener("click", (event) => {
  // 콘텐츠의 자식 클릭은 무시하고 배경 자체를 클릭한 경우만 닫는다.
  if (event.target !== event.currentTarget) return;
  closeDialog();
});

모든 이벤트가 같은 방식으로 버블링하지는 않는다

위임을 모든 이벤트에 똑같이 적용할 수는 없다. 예를 들어 focusblur는 일반적인 방식으로 버블링하지 않는다. 위임이 필요하다면 버블링하는 focusin, focusout을 고려하거나 캡처링 단계에서 처리한다. mouseenter, mouseleave 대신 상황에 따라 mouseover, mouseoutrelatedTarget 검사를 사용할 수도 있다.

스크롤·터치 이벤트에서는 기본 동작 취소 여부도 중요하다. 리스너가 스크롤을 취소하지 않는다면 { passive: true }로 의도를 표현할 수 있지만, 이 리스너 안에서는 preventDefault()를 사용할 수 없다.

document.addEventListener(
  "touchstart",
  recordTouchStart,
  { passive: true },
);

Shadow DOM과 composedPath

웹 컴포넌트의 Shadow DOM 경계를 지나는 이벤트는 외부에서 본 target이 다시 지정될 수 있다. 내부 구현을 외부에 그대로 노출하지 않기 위해서다. 이때 실제 전파 경로가 필요하면 composedPath()를 확인할 수 있다.

toolbar.addEventListener("click", (event) => {
  const actionElement = event
    .composedPath()
    .find((node) => node instanceof HTMLElement && node.dataset.action);

  if (!actionElement) return;
  runToolbarAction(actionElement.dataset.action);
});

모든 이벤트가 Shadow DOM 경계를 통과하는 것도 아니다. 커스텀 이벤트를 외부에 알릴 목적이라면 이벤트 계약에서 bubblescomposed를 명시한다.

this.dispatchEvent(
  new CustomEvent("product-selected", {
    detail: { productId: "p-101" },
    bubbles: true,
    composed: true,
  }),
);

실전 점검 목록

위임 코드를 작성할 때

  • 리스너를 기능과 가장 가까운 안정적인 조상에 등록했는가?
  • target이 버튼의 자식 요소일 수 있음을 고려했는가?
  • closest()로 찾은 요소가 위임 경계 안에 있는지 확인했는가?
  • 전파 중단이 다른 기능까지 끊지 않는가?
  • 사용하는 이벤트가 실제로 버블링하는가?
  • 키보드 입력과 접근성 동작도 동일한 기능을 실행할 수 있는가?

이벤트 위임은 공통 조상에 리스너를 하나 두고 event.target에서 실제 대상을 찾는 패턴이다. 동적으로 추가되는 자식도 처리할 수 있다.

결론

이벤트 위임은 공통 조상에 리스너 하나를 두는 최적화가 아니라, 이벤트 전파 경로를 이용해 처리 책임을 모으는 패턴이다. targetcurrentTarget을 구분하고, 내부 요소 클릭은 closest()로 보정하며, 전파를 막기보다 처리 경계를 검사하는 것이 안전하다. 위임할 이벤트가 실제로 버블링하는지까지 확인해야 예외가 쌓이지 않는다.

관련 노트